— 8 min read
Spring의 3대 핵심기술인 IoC/DI, 서비스 추상화, AOP를 애플리케이션 개발에 활용하여 새로운 기능을 만들어본다. 이를 통해 스프링의 개발철학과 추구하는 가치, 스프링 사용자에게 요구되는 것을 살펴본다.
앞에서 했던 UserDao에서 마지막으로 개선할 점 : SQL을 Dao에서 분리하기
어떻게 SQL을 Dao에서 분리할까?
XML 설정을 이용한 분리: SQL을 xml설정파일의 프로퍼티 값으로 정의해서 DAO에 주입함
1) 개별 SQL프로퍼티 방식
1public class UserDaoJdbc implements UserDao {2 private String sqlAdd;3 ...4}
1<bean id="userDao" class="~~">2 <property name="sqlAdd" value="insert into ~~" />3...
2) SQL 맵 프로퍼티 방식
SQL이 점점 많아지면 그때마다 DAO에 DI용 프로퍼티를 주입하기 귀찮으니까, SQL들을 맵에 담아두자.
1public class UserDaoJdbc implements UserDao {2 private Map<String, String> sqlMap;3 ...4}
1<property name="sqlMap">2 <map>3 <entry key="userAdd" value="insert into users(id, name, password, level, login, recommend, email) values(?,?,?,?,?,?,?)" />4 <entry key="userGet" value="select * from users where id = ?" />5 ...6 </map>7</property>
⇒ 문제점:
SQL 제공 서비스
xml 방식의 문제점을 해결하기 위해, DAO가 사용할 SQL을 제공해주는 기능을 독립시키자.
인터페이스를 어떻게 만들어야 할까?
1// SQL에 대한 키 값을 전달하면 그에 해당하는 SQL을 돌려주면 된다.2public interface SqlService {3 String getSql(String key) throws SqlRetrievalFailureException;4}
이렇게 정의하고, UserDaoJdbc는 SqlService를 DI받아 인스턴스 변수로 갖고있다가 사용함.
구현
1public class SimpleSqlService implements SqlService {2 private Map<String, String> sqlMap; // sql정보는 이 프로퍼티에 <map>을 이용해 등록.3 ...4}
⇒ 이렇게 해두면 모든 DAO는 sql을 어디에 저장해두고 가져오는지에 대해 신경쓰지 않아도 되고 그저 SqlService 인터페이스 타입의 빈을 DI 받아서 필요한 SQL을 가져다 쓰기만 하면 된다. SqlService도 DAO에 영향을 주지 않고 다양한 방법으로 구현할 수 있다.
7.1의 SqlService 인터페이스의 구현을 발전시켜보자.
sql을 저장해두는 독립적인 파일을 이용하자.
JAXB는 xml에 담긴 정보를 파일에서 읽어오는 방법 중 하나. xml 정보를 오브젝트처럼 다룰 수 있어 편리함.
언제 JAXB를 사용해 XML문서를 가져올까?
1public class XmlSqlService implements SqlService {2 private Map<String, String> sqlMap = new HashMap<String, String>(); // 읽어온 SQL을 저장해둘 맵3 4 public XmlSqlService() { // 생성자에서 xml읽어오기5 String contextPath = Sqlmap.class.getPackage().getName();6 try {7 JAXBContext context = JAXBContext.newInstance(contextPath);8 Unmarshaller unmarshaller = context.createUnmarshaller();9 InputStream is = UserDao.class.getResourceAsStream("sqlmap.xml");10 Sqlmap sqlmap = (Sqlmap)unmarshaller.unmarshal(is);11 for(SqlType sql : sqlmap.getSql()) {12 sqlMap.put(sql.getKey(), sql.getValue()); // 읽어온 SQL을 맵으로 저장해둔다.13 }14 } catch (JAXBException e) {15 throw new RuntimeException(e);16 }17 }
@PostConstruct
애노테이션을 통해 초기화 메서드로 지정해줄 수 있다.⇒ 분리 가능한 관심사를 나눠서 독립적인 책임을 뽑아보자.
SQL 정보를 외부 리소스로부터 읽어오는 책임
읽어온 SQL을 보관해두고 있다가 필요할 때 제공하는 책임
한번 가져온 SQL을 필요에 따라 수정(나중에 다룸)
SqlReader는 읽어온 다음에 SqlRegistry에 전달해서 등록되게 해야하는데
1sqlReader.readSql(sqlRegistry);
위의 자기참조 빈에서 독립적인 빈으로 나눈다.
이렇게 빈을 나눠놓으면 클래스가 늘어나고 의존관계 설정도 다 해줘야하는 부담이 있음
1public class DefaultSqlService extends BaseSqlService{2 public DefaultSqlService() { // 생성자에서 자신이 사용할 디폴트 의존 오브젝트를 스스로 DI.3 setSqlReader(new JaxbXmlSqlReader());4 setSqlRegistry(new HashMapSqlRegistry());5 }6}
DI를 사용한다고 해서 항상 모든 프로퍼티 값을 설정에 넣고 모든 의존 오브젝트를 일일이 빈으로 지정할 필요 없다. 자주 사용되는 오브젝트는 디폴트로. 나중에 대신 사용하고싶은 구현체가 있으면 설정에 프로퍼티를 추가하면 된다.
이 방법의 단점
@PostConstruct
초기화 메서드에서 프로퍼티가 없는 경우에만 디폴트 오브젝트를 만드는 방법을 쓰자.JaxbXmlSqlReader를 발전시켜보자:
OXM추상화 (XML과 자바 오브젝트를 매핑해서 상호 변환해주는 기술)
1public interface Unmarshaller {2 ...3 Object unmarshal(Source source) throws IOException, XmlMappingException;4}
서비스 추상화를 통해 Jaxb → Castor로 바꿀 때 bean설정만 Castor용 구현 클래스로 변경해주면 손쉽게 수정할 수 있다.
추상화된 OXM 기능을 이용하는 SqlService 구현
1// SqlReader를 SqlService안에 포함시켜 하나의 빈으로 등록. 2// SqlReader 구현을 외부에서 사용 못하도록 제한하고 스스로 최적화된 구조로 만들기3public class OxmSqlService implements SqlService {4 private final OxmSqlReader oxmSqlReader = new OxmSqlReader();56 private class OxmSqlReader implements SqlReader {7...8 }9}
위임을 이용한 BaseSqlService 재사용
loadSql()
과 getSql()
의 핵심 메서드 구현 코드가 BaseSqlService와 중복됨.loadSql()
과 getSql()
구현 로직은 BaseSqlService에만 두고 OxmSqlService는 설정과 기본 구성을 변경해주기 위한 어댑터 처럼 BaseSqlService 앞에 두기
1public class OxmSqlService implements SqlService{2 private final BaseSqlService baseSqlService = new BaseSqlService();34 @PostConstruct5 public void loadSql() {6 // OxmSqlService의 프로퍼티를 통해서 초기화된 SqlReader와 SqlRegistry를 실제 작업을 위임할 대상인 baseSqlService에 주입한다.7 this.baseSqlService.setSqlReader(this.oxmSqlReader);8 this.baseSqlService.setSqlRegistry(this.sqlRegistry);9 10 // SQL을 등록하는 초기화 작업을 baseSqlService에 위임한다.11 this.baseSqlService.loadSql();12 }
Resource 추상화
같은 클래스패스 외의 루트 클래스패스 또는 웹 상의 리소스 등 다양한 위치에 존재하는 리소스에 대해 단일화된 접근을 하려면 어떻게 해야할까?
스프링에는 Resource 라는 추상화 인터페이스가 정의되어있다. 서비스 추상화 오브젝트와 달리 빈으로 등록해서 쓰지는 않고, 값으로 취급한다.
접두어(문자열)로 정의된 리소스를 실제 Resource타입으로 변환해주는 ResourceLoader를 제공.
<property>
태그의 value를 통해 문자열로 값을 넣음. 이걸 변환해서 오브젝트로 만든다.1<property name="myFile" value="classpath:com/e/mypj/data/myfile.txt" />2<property name="myFile" value="file:/data/myfile.txt" />3<property name="myFile" value="https://www.adfjkdlfjlk.com/myfile.txt" />
OxmSqlService에 적용하기
1private class OxmSqlReader implements SqlReader {2 private Unmarshaller unmarshaller;3 private static final String DEFAULT_SQLMAP_FILE = "sqlmap.xml";4 private Resource sqlmap = new ClassPathResource(DEFAULT_SQLMAP_FILE, UserDao.class);5 6 public void setSqlmap(Resource sqlmap) {7 this.sqlmap = sqlmap;8 }9 public void read(SqlRegistry sqlRegistry) {10 try {11 Source source = new StreamSource(sqlmap.getInputStream());12 // 리소스 종류에 상관없이 스트림으로 가져올 수 있다.13 ...14 }15}
1<bean id="sqlService" class="springbook.user.sqlservice.OxmSqlService">2 <property name="sqlMap" value="classpath:springbook/user/dao/sqlmap.xml" />3// 프로퍼티의 value 부분을 file: 또는 http: 접두어로 바꿔서 다른 데서 가져올 수 있다4 ...5</bean>
권장되진 않지만, 서버 운영중에 SQL을 변경해야할 수도 있다. 애플리케이션을 재시작하지 않고 특정 SQL내용만 변경하고 싶다면 어떻게 해야할까?
인터페이스 분리 원칙
이라고 부른다.때로는 인터페이스를 여러 개 만드는 대신 기존 인터페이스를 상속을 통해 확장하는 방법도 사용된다.
위 그림에서 MySqlRegistry의 기본 기능에서 이미 등록된 SQL을 변경하는 기능을 넣어서 확장하고 싶다고 할 때, 어떻게 해야할까?
1public interface UpdatableSqlRegistry extends SqlRegistry {2 public void updateSql(String key, String sql) throws SqlUpdateFailureException;3}
위에서 설계한 UpdateableSqlRegistry를 구현해보자.
HashMap 대신 동기화된 해시 데이터 조작에 최적화되게 만들어진 ConcurrentHashMap을 사용해서 구현하기
안전하면서 성능이 보장되는 동기화된 HashMap이다.
update기능을 위한 Test 작성
Test를 통과하는 UpdateableSqlRegistry 구현
1public class ConcurrentHashMapSqlRegistry implements UpdatableSqlRegistry {2 private Map<String, String> sqlMap = new ConcurrentHashMap<String, String>();3 4 @Override5 public void registerSql(String key, String sql) {6 sqlMap.put(key, sql);7 }89 @Override10 public String findSql(String key) throws SqlNotFoundException {11 ...12 }1314 @Override15 public void updateSql(String key, String sql) throws SqlUpdateFailureException {16 if(sqlMap.get(key) == null) {17 throw new SqlUpdateFailureException(key + "에 해당하는 SQL을 찾을 수 없습니다.");18 }19 sqlMap.put(key, sql);20 }2122 @Override23 public void updateSql(Map<String, String> sqlmap) throws SqlUpdateFailureException {24 for(Map.Entry<String, String> entry : sqlmap.entrySet()) {25 updateSql(entry.getKey(), entry.getValue());26 }27 }2829}
내장형DB를 사용해서 구현하기
저장되는 데이터 양이 많아지고 잦은 조회와 변경이 일어나는 환경이라면, db를 쓰되 별도로 구축하면 비용이 너무 크니 내장형 DB를 사용하자.
내장형 DB는 애플리케이션 내에서 DB를 기동시키고 초기화 SQL스크립트를 실행시키는 초기화 작업이 별도로 필요하다.
스프링은 초기화 작업을 지원하는 편리한 내장형DB 빌더를 지원한다.
1new EmbeddedDatabaseBuilder()2 .setType(내장형DB종류)3 .addScript(초기화 db script리소스)4 ...5 .build();
1public class EmbeddedDbSqlRegistry implements UpdatableSqlRegistry{2 SimpleJdbcTemplate jdbc;3 public void setDataSource(DataSource dataSource) {4 this.jdbc = new SimpleJdbcTemplate(dataSource); 5 // 인터페이스 분리원칙을 적용하여 EmbeddedDatabase대신 Datasource 타입을 DI 받게 함 6 }7 ...8 @Override9 public void updateSql(String key, String sql) throws SqlUpdateFailureException {10 // update()는 SQL 실행 결과로 영향을 받은 레코드의 개수를 리턴한다. \11 // 이를 이용하면 주어진 키(key)를 가진 SQL이 존재했는지를 간단히 확인할 수 있다.12 int affected = jdbc.update("update sqlmap set sql_ = ? where key_ = ?", sql, key);13 if(affected == 0) {14 throw new SqlUpdateFailureException(key + "에 해당하는 SQL을 찾을 수 없습니다.");15 }16 }17}
여러개의 SQL을 맵으로 전달받아 한번에 수정할 경우, 중간에 예외가 발생하면??? → 이런 작업은 반드시 트랜잭션 안에서 일어나야 한다.
스프링에서 트랜잭션 적용 시 트랜잭션 경계가 DAO 밖에 있고 범위가 넓으면 AOP를 사용. but SqlRegistry라는 제한된 오브젝트 내에서 간단한 트랜잭션이므로 트랜잭션 추상화 API를 직접 사용해보자.
1@Test2 public void transactionalUpdate() {3 checkFindResult("SQL1", "SQL2", "SQL3"); // 초기 상태를 확인4 5 Map<String, String> sqlmap = new HashMap<String, String>();6 sqlmap.put("KEY1", "Modified1");7 sqlmap.put("KEY9999!@#$", "Modified9999"); // 두 번째 SQL의 키를 존재하지 않는 것으로 지정해서 실패하게 만들기8 try {9 sqlRegistry.updateSql(sqlmap);10 fail();11 }catch (SqlUpdateFailureException e) { 12 }13 // 첫번째 SQL은 정상적으로 수정했지만 트랜잭션이 롤백되기 때문에 다시 변경 이전 상태로 돌아와야한다. 14 // 트랜잭션이 적용되지 않는다면 변경된 채로 남아서 테스트는 실패할 것이다.15 checkFindResult("SQL1", "SQL2", "SQL3");16 }
1@Override2 public void updateSql(final Map<String, String> sqlmap) throws SqlUpdateFailureException {3 transactionTemplate.execute(new TransactionCallbackWithoutResult() {4 @Override5 protected void doInTransactionWithoutResult(TransactionStatus status) {6 for(Map.Entry<String, String> entry : sqlmap.entrySet()) {7 updateSql(entry.getKey(), entry.getValue());8 }9 }10 });11 }
<bean>
태그 → 빈 오브젝트생성, new 키워드→ 인스턴스 생성 등7.6절에서는 지금까지의 예제 코드를 스프링 3.1의 DI스타일로 바꾸는 과정을 설명한다.
XML을 없애자
1@ContextConfiguration(classes=TestApplicationContext.class) // 원래 xml로 설정하던걸 변경함2public class UserDaoTest {
@ImportResource
로 가져오고 같이 합쳐서 쓰다가 단계적으로 옮기자.<context:annotation-config />
제거@PostConstruct
와 같은 표준 애노테이션을 인식해서 자동으로 메서드를 실행해줬음.@Configuration
이 붙은 설정 클래스를 사용하는 컨테이너가 사용되면, 컨테이너가 직접 @PostConstruct
를 처리하는 빈 후처리기를 등록해준다.<bean>
의 전환@Bean
이 붙은 public 메서드. 메서드 이름= <bean>
의 id.
리턴값은 구현클래스보다 인터페이스로 해야 DI에 따라 구현체를 자유롭게 변경할 수 있다.
근데 메서드 내부에서는 빈의 구현 클래스에 맞는 프로퍼티 값 주입이 필요함.
1@Bean2 public DataSource dataSource() {3 SimpleDriverDataSource dataSource = new SimpleDriverDataSource ();4 ...5 return dataSource;6 }
Spring 3.1은 xml에서 자주 사용되는 전용 태그를 @Enable
로 시작하는 애노테이션으로 대체할 수 있도록 애노테이션을 제공함.
ex. <tx:annotation-driven />
→ @EnableTransactionManagement
자동 와이어링
자동와이어링을 이용하면 컨테이너가 이름/타입 기준으로 주입될 빈을 찾아준다 → 프로퍼티 설정을 직접 해주는 코드를 줄일 수 있다.
setter에 @Autowired
붙이면 → 파라미터 타입을 보고 주입 가능한 타입의 빈을 모두 찾음. 주입 가능한 빈이 1개일땐 스프링이 setter를 호출해서 넣고, 2개 이상일때는 그 중에서 프로퍼티와 동일한 이름의 빈을 찾아 넣고 없으면 에러.
1@Autowired2public void setDataSource(DataSource dataSource) {3 this.jdbcTemplate = new JdbcTemplate(dataSource);4}
setter에서 필드 그대로 넣는다면 필드에 직접 @Autowired
를 적용할 수 있다.
autowiring 장점: DI 관련 코드를 대폭 줄일 수 있음
autowiring 한계: 다른 빈과 의존관계가 어떻게 맺어져있는지 파악하기 어렵다 ㅠ
@Component
클래스에 붙이면 빈 스캐너를 통해 자동으로 빈으로 등록될 대상이 된다.
@Component
annotation이 달린 클래스를 자동으로 찾아 빈으로 등록해주게 하려면 빈 스캔 기능을 사용하겠다는 annotation 정의가 필요함: @ComponentScan(basePackages="springbook.user")
@SpringBootApplication
에 @Component
가 포함되어있음 😉@Component
로 추가되는 빈의 id는 별도 지정 없으면 클래스 이름의 첫 글자를 소문자로 바꿔서 사용.
@Component("userDao")
와 같이 함@Component를 메타 애노테이션으로 갖고 있는 애노테이션들
메타 애노테이션?
1@Component2public @interface Service { // annotation은 @interface 키워드로 정의한다.3...4}
bean 스캔 검색 대상 + 부가적인 용도의 마커로 사용하기 위한
여기까지 코딩하고 돌리면 😢
⇒ spring 3.2.9로 업데이트 필요
컨텍스트 분리
성격이 다른 DI정보를 분리해보자. - ex. 테스트를 위해 만든 빈은 테스트에만 사용돼야하고 실제 애플리케이션에는 포함하지 않도록
@Configuration
이 붙은 빈 설정 파일을 테스트에만 쓰이는 설정(AppContext)과 실서비스 동작 시 쓰이는 설정(TestAppContext) 두가지로 분리
실서비스에는 AppContext만 참조, 테스트에서는 AppContext, TestAppContext 두가지 모두 사용
1@ContextConfiguration(classes={TestAppContext.class, AppContext.class})2public class UserDaoTest {
@Import
SQL서비스처럼 다른 애플리케이션에서도 사용할 수 있고, 독립적으로 개발/변경될 가능성이 높은 서비스는 독립적인 모듈처럼 취급하는게 좋다.
1@Import(SqlServiceContext.class)2public class AppContext {
테스트와 운영환경에서 각기 다른 빈 정의가 필요한 경우 - 양쪽 모두 필요하면서 내용만 다른 것들은 설정정보를 변경하고 조합하는 것으로는 한계가 있음 (ex. mailSender)
⇒ 실행환경에 따라 빈 구성이 달라지는 내용을 프로파일로 정의해서 만들어두고, 실행시점에 지정해서 사용
설정 클래스 단위로 지정하고 context를 쓰는곳에서 active profile을 지정
1@Configuration2@Profile("test")3public class TextAppContext {
1@ActiveProfiles("test")2@ContextConfiguration(classes=AppContext.class)3public class UserServiceTest {
정말 active profile이 제대로 적용되어서 지정한 프로파일의 빈설정만 적용되고 나머지는 무시된건지 확인하고싶다면?
@Autowired
로 주입받아서 이용하면 된다.쪼개고보니 파일이 많아 전체 구성을 살펴보기가 어렵다 → static nested class를 써서 하나의 파일로 합치기
db 연결정보와 같은 부분은 환경에 따라 다르게 설정 && 필요에 따라 쉽게 변경할 수 있어야함 → 프로퍼티 파일에 저장해놓고 쓰자.
컨테이너가 프로퍼티 값을 가져오는 대상을 property source라고 한다.
1@PropertySource("/database.properties")2public class AppContext {
이렇게 등록해두면 컨테이너가 관리하는 Environment 타입의 환경 오브젝트에 프로퍼티가 저장된다.
1@Autowired Environment env;23@Bean4public DataSource dataSource {5 ...6 try {7 // Class타입이어야해서 타입 변환이 필요.8 ds.setDriverClass(Class<? extends java.sql.Driver>Class.forName(env.getProperty("db.driverClass"));9 } catch (ClassNotFoundException e) {10 ...11 }12 ds.setUrl(env.getProperty("db.url"));13}
프로퍼티 값을 직접 DI받을 수도 있다. @Value
로 필드 주입.
1@Bean2public static PropertySourcesPlaceholderConfigurer placeholderConfigurer() {3 return new PropertySourcesPlaceholderConfigurer();4}
에러
SqlServiceContext를 sql서비스 라이브러리 모듈에 포함시켜서 재사용 가능하게 하기.
1private class OxmSqlReader implements SqlReader {2 private Unmarshaller unmarshaller;3 private Resource sqlmap = new ClassPathResource("sqlmap.xml", UserDao.class);
1@Bean2 public SqlService sqlService() throws IOException {3 ...4 sqlService.setSqlmap(new ClassPathResource("sqlmap.xml", UserDao.class);5 return sqlService;6 }
여전히 UserDao에 종속된 정보가 남아있어서 다른 애플리케이션에서 SqlServiceContext를 재사용할수가 없다. DI설정용 클래스인 SqlServiceContext까지 독립적인 모듈로 분리하려면 - 이것도 DI 방식을 사용한다:
1public class UserSqlMapConfig implements SqlMapConfig{2 @Override3 public Resource getSqlMapResource() {4 return new ClassPathResource("/sqlmap.xml", UserDao.class);5 }6}
1public class SqlServiceContext {2 @Autowired SqlMapConfig sqlMapConfig; // interface에 의존하게 한다.3...4}
음 그런데 리소스 위치도 빈 설정과 관련된 정보인데, 이것 때문에 새로운 클래스를 하나 추가하기보다 빈 설정을 더 간단하게 할수는 없을까?
@Autowired
를 이용할 수 있다. AppContext가 SqlMapConfig 인터페이스를 직접 구현하게 해보자.// 여기까지 해놓으면, SQL서비스가 필요한 애플리케이션은 메인 설정클래스에서 @Import
로 SqlServiceContext 빈설정을 추가하고 SqlMapConfig를 구현해서 SQL매핑 파일 위치를 지정하면 됨.
@Enable*
annotation
모듈화된 빈 설정을 가져올 때, @Enable
로 시작하는 메타 어노테이션을 사용하자.
@Repository
, @Service
처럼. 빈의 종류나 계층을 나타내고 특정 애노테이션이 달린 빈만 AOP로 부가기능을 넣을 수도 있다.1@Import(value = SqlServiceContext.class)2public @interface EnableSqlService {3}